Skip to content

feat(sdk): resolve iam token placeholders in network transform callbacks - #1616

Open
mishushakov wants to merge 5 commits into
iam-sdk-featurefrom
network-transform-callback
Open

feat(sdk): resolve iam token placeholders in network transform callbacks#1616
mishushakov wants to merge 5 commits into
iam-sdk-featurefrom
network-transform-callback

Conversation

@mishushakov

@mishushakov mishushakov commented Jul 27, 2026

Copy link
Copy Markdown
Member

Stacked on #1606 (iam-sdk-feature) — merge that one first. This is the second half of SDK-245: it makes the workload tokens registered by Sandbox.create's iam option usable, by letting a network rule's transform be a callback that receives placeholder strings the egress proxy resolves per request.

iam.tokens.aws is the literal string ${e2b.identity.tokens.aws} (the frozen backend spelling — a placeholder can only select a persisted named token, never an inline audience or claim). The SDK never resolves it: the wire payload carries the placeholder and the proxy substitutes a freshly minted JWT-SVID when it forwards the request, so the token value never reaches SDK-side code or the sandbox.

Referencing a name that isn't registered in iam.tokens fails with InvalidArgumentError / InvalidArgumentException listing the names that are — the proxy never turns an unregistered name into a token, so a typo would otherwise surface as a confusing auth failure at the destination. updateNetwork / update_network accepts the same callbacks, but its payload carries no iam config, so token names can't be validated client-side there and any name resolves to its placeholder.

Static transform: { headers } objects keep working unchanged (including hand-written ${e2b.identity.tokens.<name>} strings, which stay the escape hatch for tokens the SDK doesn't know about).

Only { iam } is exposed on the context for now — ${e2b.sandboxId} / ${e2b.teamId} / ${e2b.executionId} from the older prototype are not part of the current backend design, so sandbox can be added later when there is something to resolve.

Usage

import { Sandbox, Secret } from 'e2b'

const sandbox = await Sandbox.create({
  iam: {
    tokens: {
      aws: Secret.iamToken({ audience: 'sts.amazonaws.com', tokenType: 'JWT-SVID' }),
    },
  },
  network: {
    // Only allow egress to hosts that have rules registered.
    allowOut: ({ rules }) => [...rules.keys()],
    rules: {
      'api.internal.example.com': [
        {
          transform: ({ iam }) => ({
            headers: { Authorization: `Bearer ${iam.tokens.aws}` },
          }),
        },
      ],
    },
  },
})
from e2b import Sandbox, Secret

sandbox = Sandbox.create(
    iam={
        "tokens": {
            "aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID"),
        },
    },
    network={
        "allow_out": lambda ctx: list(ctx.rules.keys()),
        "rules": {
            "api.internal.example.com": [
                {
                    "transform": lambda ctx: {
                        "headers": {"Authorization": f"Bearer {ctx.iam.tokens['aws']}"},
                    },
                },
            ],
        },
    },
)

Both send:

{
  "iam": { "tokens": { "aws": { "audience": "sts.amazonaws.com", "tokenType": "JWT-SVID" } } },
  "network": {
    "allowOut": ["api.internal.example.com"],
    "rules": {
      "api.internal.example.com": [
        { "transform": { "headers": { "Authorization": "Bearer ${e2b.identity.tokens.aws}" } } }
      ]
    }
  }
}

Notes

  • allowOut / deny_out selectors run before transforms are resolved, so ctx.rules still hands back the rules you passed — a rule's transform there is the union (object or callback), not the materialized object. The getInfo view keeps its own narrowed SandboxNetworkRuleInfo type.
  • Every lookup form on iam.tokens is guarded, not just [name]: Python's map is a Mapping whose __getitem__ owns resolution (so .get('typo') raises instead of returning None), and membership ('aws' in ctx.iam.tokens / 'aws' in iam.tokens) answers "is it registered?" without raising so a callback can branch on it.
  • A callback must be synchronous and return a plain transform object; a promise (from an async callback), an array, a Map/Date/class instance, or a missing return value is rejected with an actionable error rather than silently creating a rule with no headers. The awaitable is closed/caught so you don't also get an unawaited-coroutine warning or an unhandled rejection.
  • Token lookup checks own keys only, so an unregistered name that collides with an object member (constructor, __proto__) reports as unregistered instead of resolving to a built-in. The four properties the runtime itself reads (toJSON, then, toString, valueOf) still resolve normally, so serializing, awaiting or coercing the map does not trip the guard.

Tests

New payload-level tests: JS tests/sandbox/networkTransform.test.ts (msw), Python tests/shared/sandbox/test_network_transform.py — shared rather than mirrored into the sync and async suites, since they only exercise the shared builders. They cover placeholder resolution, enumerating and membership-testing registered tokens, JSON.stringify of the context not tripping the guard, static transforms staying byte-identical, transform: null, the unregistered-name rejection through both [name] and .get(), the no-iam rejection, non-transform and async return values, and the permissive updateNetwork path.

Verified against production on all three surfaces (JS, sync Python, async Python), where:

  1. a static transform carrying Bearer ${e2b.identity.tokens.aws} is accepted by validateNetworkRules and round-trips through getInfo / get_info unchanged;
  2. the callback-resolved payload reaches the API and is answered with the expected team-gating error (400: Sandbox IAM workload tokens are not available for your team.), since iam is still feature-flagged;
  3. a misspelled token name is rejected client-side before any request is made.

Proxy-side substitution of the placeholder ships separately in belt (EN-1864); until then the header value is forwarded verbatim, which is why there is no end-to-end injection test here.

Part of SDK-245.

mishushakov and others added 2 commits July 27, 2026 16:46
A network rule's `transform` can now be a callback receiving a context of
placeholder strings, so a workload identity token registered in the `iam`
option can be injected into egress requests without the SDK ever seeing
its value: `iam.tokens.aws` resolves to the literal
`${e2b.identity.tokens.aws}`, which the egress proxy substitutes with a
freshly minted token per request.

Referencing a token that is not registered in `iam.tokens` fails with
InvalidArgumentError / InvalidArgumentException — the proxy drops
placeholders it cannot resolve, so a typo would otherwise silently strip
the header. The update-network endpoint carries no iam config, so its
token names are unknown client-side and any name resolves there.

Co-Authored-By: Claude <noreply@anthropic.com>
…e destination

The deployed egress proxy forwards header values verbatim, so an
unregistered token name reaches the destination as literal placeholder
text today and is dropped once substitution ships. Either way it never
becomes a token, which is what the client-side check is for.

Co-Authored-By: Claude <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 27, 2026

Copy link
Copy Markdown

SDK-245

@cla-bot cla-bot Bot added the cla-signed label Jul 27, 2026
@changeset-bot

changeset-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 11930bb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
e2b Minor
@e2b/python-sdk Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cursor

cursor Bot commented Jul 27, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes affect egress header injection and workload-identity wiring; mistakes would show up as auth failures at destinations rather than leaking tokens through the SDK.

Overview
No implementation errors identified in the diff. JS and Python both resolve synchronous transform callbacks at request-build time, wire placeholders instead of token values, validate registered IAM names on sandbox create, and skip that validation on updateNetwork / update_network where no IAM payload is sent.

Reviewed by Cursor Bugbot for commit 11930bb. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from 7fe9a16. Download artifacts from this workflow run.

JS SDK (e2b@2.36.2-network-transform-callback.0):

npm install ./e2b-2.36.2-network-transform-callback.0.tgz

CLI (@e2b/cli@2.16.1-network-transform-callback.0):

npm install ./e2b-cli-2.16.1-network-transform-callback.0.tgz

Python SDK (e2b==2.35.0+network.transform.callback):

pip install ./e2b-2.35.0+network.transform.callback-py3-none-any.whl

Comment thread packages/python-sdk/e2b/sandbox/sandbox_api.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e206170ace

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts Outdated
Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts
Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts
Comment thread packages/python-sdk/e2b/sandbox/sandbox_api.py Outdated
mishushakov and others added 2 commits July 27, 2026 17:04
Review pass on the callback path:

- Python's placeholder map was a dict subclass hooking __missing__, which
  `.get()` never calls: `ctx.iam.tokens.get('awz')` returned None and
  shipped a literal "Bearer None" header, and on the update path `.get()`
  did that even for a correctly spelled token. It is now a Mapping whose
  __getitem__ owns both resolution and validation, with membership
  answering "is it registered?" without raising, like `in` in JS.
- The JS return-value guard accepted any object, so an async callback's
  promise serialized to `"transform": {}` — a rule created without the
  headers it exists for. Promises and arrays are now rejected, with a
  dedicated "must be synchronous" error in both SDKs; the awaitable is
  closed/caught so the caller does not also get an unawaited-coroutine
  warning or an unhandled rejection.
- JS forwarded an explicit `transform: null` as `null` while Python read
  it as no transform.
- The JS token proxy threw on the runtime's own `toJSON`/`then` probes, so
  `JSON.stringify(iam.tokens)` inside a callback aborted create.
- Document the permissive update-network path in `SandboxNetworkUpdate`
  and the changeset instead of only in an internal comment.
- Payload tests move to tests/shared/sandbox, which is what that directory
  is for — they only exercise the shared builders, so the sync and async
  copies were 173 duplicated lines.

Co-Authored-By: Claude <noreply@anthropic.com>
Review feedback on #1616:

- The token proxy gated on `prop in target`, which also matches inherited
  Object.prototype members, so an unregistered token named `constructor`
  or `__proto__` resolved to a built-in instead of being reported —
  `Bearer function Object() { [native code] }` on the wire. It now checks
  own keys, with the four properties the runtime itself reads (`toJSON`,
  `then`, `toString`, `valueOf`) still resolving normally so serializing,
  awaiting or coercing the map does not trip the guard. Python's mapping
  already treated those names as unregistered.
- The callback return check accepted any object, so a `Map`, `Date` or
  class instance was forwarded and serialized to `{}` — a rule created
  without the headers it exists for. It now requires a plain object,
  mirroring Python's `isinstance(transform, Mapping)`.

Co-Authored-By: Claude <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f0f8f35. Configure here.

Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts
The `get` trap moved to own keys in the previous commit, but `in` still
went through the default `has`, which walks the prototype chain: with a
name from config, `'constructor' in iam.tokens` was true while
`iam.tokens.constructor` threw. Membership answers "is this token
registered?", so it now checks own keys too, matching Python's
`_IamTokenPlaceholders.__contains__`.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant